[Next.js+Vercel+microCMS] microCMS と Next.js でブログを作る
microCMS がとても気になっていたので、シンプルなブログを作ってみることにしました。
完成品は上記のリポジトリにあります。
今回作るブログについて
- API は microCMS を使う
- ホームではブログの一覧を表示する
- ブログの記事はタグを持っている
- 記事についているタグのリンクはタグ一覧のリストに遷移する
- HTTP クライアントラッパーの aspida を使う
- 下書きの preview モードを実装する
- Next.js で ISG
ISG にすることで Webhook を使用しなくても、microCMS で記事を公開状態にするだけでサイト側でも更新されます。Vercel にデプロイするのであれば特に設定が必要になることもありません。
記事の更新ごとに毎回ビルドされることもないことを考えると SSG よりも気軽に記事の更新ができそうです。
microCMS で API を作成する
さっそく microCMS で API を作成していきましょう。アカウントやサービスの作成は公式ドキュメントに詳しくありますので、そちらを御覧ください。日本のサービスのため、ドキュメントがわかりやすい日本語で嬉しいですね。
今回作る API は次のとおりです。
/sitedata
- サイトデータ
サイト全体のデータを登録しておくオブジェクト形式の API です。今回はサイト名のみを登録します。
- エンドポイント
/sitedata
フィールド ID | 表示名 | 種類 | その他 |
---|---|---|---|
title |
ブログタイトル | テキストフィールド | 必須項目 |
オブジェクト形式の API はこのような設定系のデータに使用できます。
/tags
- タグ
タグを登録しておくリスト形式の API です。
- エンドポイント
/tags
フィールド ID | 表示名 | 種類 | その他 |
---|---|---|---|
name |
タグ名 | テキストフィールド | 必須項目/ユニーク |
ブログに紐付けられるタグのリストです。種類がテキストフィールドの場合、他の種類で設定できる項目の他にいくつかの設定が追加で設定できます。
ここではタグ名が重複しないため、ユニークになるように設定しています。テキストフィールドにはユニーク設定をすることができます。
リスト形式の API はこのような複数個存在するデータに使用できます。
/blogs
- ブログ
ブログコンテンツを登録しておくリスト形式の API です。
- エンドポイント
/blogs
フィールド ID | 表示名 | 種類 | その他 |
---|---|---|---|
title |
タイトル | テキストフィールド | 必須項目 |
body |
本文 | リッチエディタ | 必須項目 |
tags |
タグ | 複数コンテンツ参照 - タグ |
ブログのコンテンツになります。
リッチエディタを使用すると、API から HTML が戻されます。ただ改行がそのまま <br>
で出力されたり、画像にひと手間加えたくなるケースはありそうなので、こだわりがあるのであればテキストエリアを選択する必要がありそうです。今回はこだわらないのでリッチエディタを選択しています。
また、記事から登録済みのタグを登録できるようにしています。一記事に複数個の登録可能なタグなので、複数コンテンツ参照から参照コンテンツとしてタグを選択しました。
slug のようなフィールドはエンドポイントとなるコンテンツ ID で持つことになるため、フィールド ID として別途用意する必要はりません。
Next.js でブログを作っていく
Next.js については 公式サイト と、公式のリポジトリに含まれている examples が非常に参考になります。こちらでもざっくりと導入までの手順を紹介していきますが、できるだけ公式サイトを参考にしていただくことを推奨します。
インストール
yarn create next-app
上記でインストールします。What is your project named?
と質問されるのでプロジェクト名を入力します。次に TypeScript を導入していきます。
touch tsconfig.json && yarn dev
上記コマンドを叩くと、叩くコマンドが指示されるので指示されたとおりのコマンドを叩きます。
yarn add --dev typescript @types/react @types/node
自動で生成される tsconfig.json
は "strict": false
となっているため、"strict": true
に書き換えることを忘れないようにしましょう。
ESLint / Prettier
ESLint と Prettier を導入していきます。
yarn add -D eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-config-prettier eslint-plugin-import eslint-plugin-react eslint-plugin-react-hooks
あわせて以下のファイルを追加しました。
.editorconfig
root = true [*] charset = utf-8 indent_style = space indent_size = 2 insert_final_newline = true trim_trailing_whitespace = true [*.md] max_line_length = off trim_trailing_whitespace = false
.eslintrc.js
module.exports = { extends: ["eslint:recommended"], env: { browser: true, node: true, es6: true, }, parserOptions: { ecmaVersion: 2020, }, overrides: [ { files: ["**/*.{ts,tsx}"], extends: [ "eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", "prettier/@typescript-eslint", ], parser: "@typescript-eslint/parser", settings: { react: { pragma: "React", version: "detect", }, }, parserOptions: { sourceType: "module", project: "./tsconfig.json", ecmaFeatures: { jsx: true, }, }, plugins: ["@typescript-eslint", "react", "react-hooks", "import"], rules: { "no-unused-vars": "off", "import/order": [ "error", { groups: ["builtin", "external", "internal"], pathGroups: [ { pattern: "react", group: "external", position: "before", }, ], pathGroupsExcludedImportTypes: ["react"], "newlines-between": "always", alphabetize: { order: "asc", caseInsensitive: true, }, }, ], "@typescript-eslint/no-unused-vars": "error", "react/jsx-no-target-blank": "error", "react/prop-types": "off", "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "error", }, }, ], globals: { React: "writable", }, };
.prettierignore
node_modules .next yarn.lock public src/api/$api.ts
あまりデフォルトの設定を変更することが好きではないので、prettier の設定は追加していません。
Jest
今回はサンプルのサイトのためあまりテストを書くことがありませんが、Jest を導入しておきます。
yarn add -D @types/jest babel-jest jest
.babelrc
{ "presets": ["next/babel"] }
jest.config.js
module.exports = { roots: ["<rootDir>"], moduleFileExtensions: ["ts", "tsx", "js", "json", "jsx"], testPathIgnorePatterns: ["<rootDir>[/\\\\](node_modules|.next)[/\\\\]"], transform: { "^.+\\.(ts|tsx)$": "babel-jest", }, transformIgnorePatterns: ["[/\\\\]node_modules[/\\\\].+\\.(ts|tsx)$"], };
package.json
には以下のコードを追加しておきます。
{ "scripts": { "lint": "eslint 'src/**/*.{ts,tsx}' && prettier --check '**/*.{js,ts,tsx,json}'", "lintfix": "eslint --fix 'src/**/*.{ts,tsx}' && prettier --write '**/*.{js,ts,tsx,json}'", "typecheck": "tsc --pretty --noEmit", "test": "jest --watch", "test:ci": "jest --ci" } }
以上で環境まわりのインストール作業は完了となります。
aspida を使って API にアクセスする
API のリクエストを型安全にするため、aspida を使用します。
yarn add @aspida/fetch
Next9.4 以降ではすべての環境での fetch が提供されており、 polyfill が必要なくなったため、インストールは @aspida/fetch で問題ありません。
まずは API のレスポンスとクエリに使用する型を書いていきます。
microCMS では API スキーマのエクスポートもできるのですが、こちらのファイルがどういった形式なのかわからず、型を自動生成できませんでした。そのためここでは API の型を手動で記述していきます。
// src/types/api.ts // リスト形式のレスポンス用 export type ListContentsResponse<T> = { contents: T[]; totalCount: number; offset: number; limit: number; }; // オブジェクト形式のレスポンス用 export type ContentResponse<T> = { id: string; createdAt: string; updatedAt: string; publishedAt: string; revisedAt: string; } & T; // リスト形式のクエリ用 // https://document.microcms.io/content-api/get-list-contents export type GetListContentsQuery = { draftKey?: string; limit?: number; orders?: string; q?: string; fields?: string; ids?: string; filters?: string; depth?: number; }; // オブジェクト形式のクエリ用 // https://document.microcms.io/content-api/get-content export type GetContentQuery = { draftKey?: string; fields?: string; depth?: number; };
// src/types/blog.ts import { ContentResponse, ListContentsResponse } from "./api"; import { TagResponse } from "./tag"; export type BlogListResponse = ListContentsResponse<BlogResponse>; export type BlogResponse = ContentResponse<{ title?: string; body?: string; tags?: TagResponse[]; }>;
次に aspida で自動生成するためのファイルを記述していきます。デフォルトでは /api
にファイルを配置するのですが、今回は /src/api
に置きたいため、aspida.config.js
を追加します。
// aspida.config.js module.exports = { input: "src/api", };
/api/v1/blogs
の aspida 用の型定義ファイルを作成していきます。
// src/api/v1/blogs/index.ts export type Methods = { get: { query?: GetListContentsQuery; resBody: BlogListResponse; }; };
/api/v1/blogs/{content_id}
の aspida 用の型定義ファイルを作成していきます。パスに変数がある場合には _id@string
のように _
から始まる変数に、@
以降は型を指定します。
// src/api/v1/blogs/_id@string/index.ts export type Methods = { get: { query?: GetContentQuery; resBody: BlogResponse; }; };
あとは package.json
の scripts
に "api:build": "aspida"
を追加して yarn api:build
を実行するだけです。実行すると src/api/$api.ts
が生成されているはずです。
microCMS の API を叩く際に毎回 FetchConfig を書くのは面倒なので、utils/api.ts
を作成しておきます。
// utils/api.ts const fetchConfig: Required<Parameters<typeof aspida>>[1] = { baseURL: process.env.MICRO_CMS_HOST, throwHttpErrors: true, headers: { "X-API-KEY": process.env.MICRO_CMS_API_KEY ?? "", }, }; export const client = api(aspida(fetch, fetchConfig));
これで、/api/v1/blog
を叩きたいときには client.v1.blog.$get()
とするだけです。コード補完もきくので非常に快適になりました。
Next.js でページを作る
ホーム
ブログのホームにはブログの一覧を表示させます。
// src/pages/index.tsx type StaticProps = { siteData: SiteDataResponse; blogList: BlogListResponse; }; type PageProps = InferGetStaticPropsType<typeof getStaticProps>; const Page: NextPage<PageProps> = (props) => { // 省略 }; export const getStaticProps: GetStaticProps<StaticProps> = async () => { const siteDataPromise = client.v1.sitedata.$get({ query: { fields: "title" }, }); const blogListPromise = client.v1.blogs.$get({ query: { fields: "id,title" }, }); const [siteData, blogList] = await Promise.all([ siteDataPromise, blogListPromise, ]); return { props: { siteData, blogList }, revalidate: 60, }; }; export default Page;
microCMS からビルド時にデータを取得するため、getStaticProps
で props
に必要なデータをそれぞれ戻します。ホームではサイトデータとブログ一覧、2 つの API を叩く必要があるので、それぞれのリクエストを Promise.all
しています。
revalidate
には再生成までの秒数が指定できます。この例では revalidate: 60
と設定されているため、60 秒ごとに 1 回再検証されて再生成できるようになります。
SSG をしたい場合にはこの指定はいりませんが、今回のように更新される API を叩く場合には Webhook を利用して、適切なタイミングで再ビルドさせる必要がでてきます。
また NextPage
の型引数には InferGetStaticPropsType<typeof getStaticProps>
を指定していますが、このとき GetStaticProps
の型引数の指定も忘れないようにしましょう。公式ドキュメントで書かれていたので、NextPage<Foo>
のようにしていたコードを 書き換えたのですが GetStaticProps
の型引数を省略してしまっていたためすべて any
で推論されてしまい、しばらく悩まされました。
ブログ記事
ブログ記事を表示させます。先ほど作成したホームと異なる点は、動的ルートであることです。プレビューモードについては後ほど実装していきます。
// src/pages/blogs/[id]/index.tsx const Page: NextPage<PageProps> = (props) => { const { blog } = props; const router = useRouter(); if (router.isFallback) { return <div>Loading...</div>; } // 省略 }; export const getStaticPaths: GetStaticPaths = async () => { return { fallback: "blocking", paths: [], }; }; export const getStaticProps: GetStaticProps<StaticProps> = async (context) => { const { params } = context; if (!params?.id) { throw new Error("Error: ID not found"); } const id = toStringId(params.id); try { const blog = await client.v1.blogs._id(id).$get({ query: { fields: "id,title,body,publishedAt,tags", }, }); return { props: { blog }, revalidate: 60, }; } catch (e) { return { notFound: true }; } }; export default Page;
Next.js では src/pages/blogs/[id]/index.tsx
のようにページ名を [id]
のようにとすると、動的ルートを作成できます。
Next.js 10 からは { notFound: true }
とすることで、404 ページを戻すことができます。それまでのバージョンでは 200 で戻す 404 ページを作成する必要がありました。
fallback
には、こちらも Next10 から利用可能となった blocking
を指定しています。true
が指定されていたときに生じる、初回アクセスで API から取得するデータ部分がない状態で表示される動作をブロックする挙動になります。基本的に true
ではなく blocking
で指定しておけばよさそうです。
また fallback 中かどうかは NextRouter
の isFallback
で判定できるので、ローディング画面などを出しておくことができます。
プレビューの作成
プレビュー機能の実装は microCMS の公式ブログで紹介されています。microCMS のブログはとてもわかりやすく役立つ記事が多いのでとてもおすすめです。
ここでは、上記のブログでは紹介されていないプレビューモードのときには、テキストリンクを出してプレビューモードの解除ができる機能も実装してみます。
プレビューの API ルートは下記になります。コードは examples にあるコードを参考に作成して型をつけただけのものです。
// src/pages/api/preview.ts const preview = async ( req: NextApiRequest, res: NextApiResponse ): Promise<void> => { if (req.query.secret !== process.env.MICRO_CMS_PREVIEW_SECRET) { return res.status(401).json({ message: "Invalid token" }); } const id = toStringId(req.query.id); const draftKey = toStringId(req.query.draftKey); const post = await client.v1.blogs._id(id).$get({ query: { fields: "id,title,body,publishedAt,tags", draftKey, }, }); if (!post) { return res.status(401).json({ message: "Invalid contentId" }); } res.setPreviewData({ ...post, draftKey }); res.writeHead(307, { Location: `/blogs/${post.id}` }); res.end(); }; export default preview;
次にプレビューを解除するための API ルートを作成します。こちらも examples にあるコードを参考に作成していますが、プレビューの解除をしたあとにホームへ戻すのではなく、もともとプレビューしていた記事に戻したいので少しコードを加えています。やっていることはクエリに contentId
を含め、記事の存在をチェックしてからリダイレクトしているだけです。
// src/pages/api/exit-preview.ts const exitPreview = async ( req: NextApiRequest, res: NextApiResponse ): Promise<void> => { const id = toStringId(req.query.id); const post = await client.v1.blogs._id(id).$get({ query: { fields: "id" }, }); res.clearPreviewData(); res.writeHead(307, { Location: post ? `/blogs/${post.id}` : "/" }); res.end(); }; export default exitPreview;
実際にプレビューを表示するのはブログの記事を表示していたものと同じファイルを使用するので、先ほどのものに追記していきます。previewData
に draftKey
があった場合には、閲覧中である表示とプレビュー解除するためのリンクを追加しただけになります。
// src/pages/blogs/[id]/index.tsx const Page: NextPage<PageProps> = (props) => { const { blog, draftKey } = props; const router = useRouter(); if (router.isFallback) { return <div>Loading...</div>; } return ( <> {draftKey && ( <div> 現在プレビューモードで閲覧中です。 <Link href={`/api/exit-preview?id=${blog.id}`}> <a>プレビューを解除</a> </Link> </div> )} {/* 省略 */} </> ); }; export const getStaticPaths: GetStaticPaths = async () => { return { fallback: "blocking", paths: [], }; }; export const getStaticProps: GetStaticProps<StaticProps> = async (context) => { const { params, previewData } = context; if (!params?.id) { throw new Error("Error: ID not found"); } const id = toStringId(params.id); const draftKey = previewData?.draftKey ? { draftKey: previewData.draftKey } : {}; try { const blog = await client.v1.blogs._id(id).$get({ query: { fields: "id,title,body,publishedAt,tags", ...draftKey, }, }); return { props: { blog, ...draftKey }, revalidate: 60, }; } catch (e) { return { notFound: true }; } }; export default Page;
あとは microCMS の設定になりますが、こちらは該当する API の API 設定にある画面プレビューからプレビューのリンク先を指定するだけです。
プレビューを見たい場合には、ブログの投稿画面から画面プレビューで閲覧可能です。
Vercel にデプロイ
Vercel にデプロイしてみます。アカウント作成後、デプロイするリポジトリを選択します。
Next.js で作成しているので、FRAMEWORK PRESET で Next.js を選択するだけで他に設定することなくデプロイできます。今回は microCMS のためのいくつかの環境変数が必要となるので追加しています。
以上で完成です。あとは main ブランチに push するだけでデプロイされます。
さいごに
私は自分で作成しているブログを Next.js で SSG したものを Vercel にデプロイしていて、記事の文章は markdown でリポジトリ内にもたせて、一部の記事以外のコンテンツの API に contentful を利用しています。
今回作成したブログと違いはいくつもありますが、大きく異なる点は microCMS と ISG を試してみた二点になります。
contentful は便利なのですが、今回のブログのような限定的な用途だと目的にたいして、ちょっと機能が大げさすぎることと、どうも自分には若干 UI が使いにくいと感じていたため、いつか別のサービスを試してみたいなと思っていました。microCMS はそういった自分のニーズにとてもマッチしたサービスだと感じました。
また、最近はブログを書く前に調べていったことを zenn のスクラップにまとめているのですが、microCMS についてメモ書きをしていたところ、疑問点について microCMS の中の方にコメントを頂いたり、違和感のある挙動についてツイートしたところ、microCMS の公式ツイッターアカウントに反応いただいて翌日には修正される、と嬉しい体験ができました。(こういうことがあると、とてもそのサービスを好きになっちゃいますよね)
ISG については Vercel にデプロイするのであれば、という前提になってしまいますが、API のデータのちょっとした修正で Webhook を利用してすべてを再ビルドしていたことを考えると、すでに SSG をしているのであれば ISG に切り替えたほうが手軽そうだなと感じました。